#!/usr/bin/env python
# -*- coding: UTF-8 -*-
'''
@Project ：V2 
@File    ：human_in_loop_dynamic_fjsp.py
@IDE     ：PyCharm 
@Author  ：郭星
@Date    ：2025/9/20 20:35
如果考虑到工人呢，比如工人会对某些工件临时判断优先级高，实现算法和人交互协作
ChatGPT 说：

太可以了！下面给你一份单文件、可直接运行的 Python 原型，支持「人–机协同」的动态 FJSP：

作业释放时间、机器故障/修复（停机区间）

滚动重调度（RH）：每次事件触发，用小规模 GA对“剩余工序”优化，但只派发一步

人类干预接口（HumanInterface）：

软优先级：对某些作业临时加权（越快开始越好）——纳入 GA 适应度的惩罚项；

硬指令：强制下一步优先某作业（若可行）；

时窗/截至：给出作业应尽快启动的时间窗，超出有惩罚；

同一个接口既支持程序式策略（示例里用“规则驱动的人类决策”模拟），也可改成交互式（例如轮到 step() 时 input() 询问人）。
'''
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
human_in_loop_dynamic_fjsp.py
---------------------------------
Dynamic FJSP with Human-in-the-Loop:
- Job releases, machine downtimes (MTBF/MTTR -> downtime windows)
- Rolling-horizon GA over remaining ops (dispatch one step each time)
- HumanInterface for soft/hard overrides:
    * priority_boost[j]: weight to start job j sooner (soft)
    * deadline[j]: target start-by time for next op (soft penalty if late)
    * hard_next_job: force the next dispatched operation must belong to job j (if feasible)
    * update(...) hook called at each RH step

Outputs:
- Final Cmax
- Gantt chart with downtime bands
"""

import math
import random
from dataclasses import dataclass
from typing import Dict, List, Tuple, Optional
import numpy as np
import matplotlib.pyplot as plt

# -------------------------------
# Utilities
# -------------------------------
def seed_all(seed=0):
    random.seed(seed); np.random.seed(seed)

def draw_exponential(mean: float, rng: np.random.RandomState):
    if mean <= 0: return 1e12
    return float(rng.exponential(mean))

def plot_gantt_with_downtimes(m: int,
                              intervals_by_machine: Dict[int, List[Tuple[float,float,str]]],
                              downtime_by_machine: Dict[int, List[Tuple[float,float]]],
                              title="Dynamic FJSP (Human-in-the-Loop)"):
    fig, ax = plt.subplots(figsize=(12, 0.8*m + 3))
    yticks, ylabels = [], []
    for mm in range(m):
        for (st, en) in downtime_by_machine.get(mm, []):
            ax.barh(mm, en-st, left=st, color="tab:red", alpha=0.15, edgecolor=None)
        for (st, du, lab) in sorted(intervals_by_machine.get(mm, []), key=lambda x: x[0]):
            ax.barh(mm, du, left=st, color="tab:blue", alpha=0.85)
            ax.text(st + du/2, mm, lab, ha="center", va="center", fontsize=8, color="white")
        yticks.append(mm); ylabels.append(f"M{mm}")
    ax.set_yticks(yticks); ax.set_yticklabels(ylabels)
    ax.set_xlabel("Time"); ax.set_title(title)
    ax.grid(axis="x", alpha=0.3)
    plt.tight_layout(); plt.show()

# -------------------------------
# Problem modeling
# -------------------------------

@dataclass
class Operation:
    machine_time: Dict[int, int]  # feasible machine -> processing time

@dataclass
class Job:
    ops: List[Operation]
    release: float

@dataclass
class FJSP:
    jobs: List[Job]
    m: int
    @property
    def n_jobs(self): return len(self.jobs)
    @property
    def n_ops_total(self): return sum(len(j.ops) for j in self.jobs)
    def feasible_machines(self, j, k) -> List[int]:
        return list(self.jobs[j].ops[k].machine_time.keys())

# -------------------------------
# Dynamic instance generator
# -------------------------------

def build_dynamic_fjsp(n_jobs=16, m=6, ops_per_job=4, k_machines_per_op=2,
                       pt_low=1, pt_high=9, release_span=40.0,
                       mtbf=120.0, mttr=20.0,
                       horizon=400.0, seed=0) -> Tuple[FJSP, Dict[int, List[Tuple[float,float]]]]:
    rng = np.random.RandomState(seed)
    jobs=[]
    for _ in range(n_jobs):
        ops=[]
        feas_all = list(range(m))
        for _o in range(ops_per_job):
            feas = sorted(rng.choice(feas_all, size=k_machines_per_op, replace=False))
            mt = {mm: int(rng.randint(pt_low, pt_high+1)) for mm in feas}
            ops.append(Operation(machine_time=mt))
        release = float(rng.uniform(0.0, release_span))
        jobs.append(Job(ops=ops, release=release))
    pb = FJSP(jobs=jobs, m=m)
    downtime = {mm: [] for mm in range(m)}
    for mm in range(m):
        t = 0.0
        while t < horizon:
            up = draw_exponential(mtbf, rng); t += up
            if t >= horizon: break
            down = draw_exponential(mttr, rng)
            st = t; en = min(horizon, t + down)
            downtime[mm].append((st, en))
            t = en
    return pb, downtime

# -------------------------------
# Downtime-aware timing
# -------------------------------

def adjust_for_downtime(mm:int, start:float, pt:float, downtime_windows: List[Tuple[float,float]]) -> Tuple[float,float]:
    t = start
    for (st,en) in downtime_windows:
        if t < en and t >= st:
            t = en
            break
    cur = t; remaining = pt
    while remaining > 1e-9:
        next_block = None
        for (st,en) in downtime_windows:
            if en <= cur: continue
            if st >= cur + remaining: break
            if st < cur + remaining and en > cur:
                next_block = (st,en); break
        if next_block is None:
            return (t, cur + remaining)
        st,en = next_block
        if st <= cur:
            cur = en; continue
        work = st - cur
        if work >= remaining:
            return (t, cur + remaining)
        remaining -= work
        cur = en
    return (t, cur)

# -------------------------------
# Human-in-the-loop interface
# -------------------------------

class HumanInterface:
    """
    可编程/可交互的人类指挥接口：
    - priority_boost[j] >= 0：越大表示越希望“尽早开始”该job下一道工序（软）
    - deadline[j]：希望该job下一道工序在此时间前启动（软）
    - hard_next_job：强制下一步必须来自该 job（若不可行则回退软约束）
    - weights：惩罚权重（w_prio, w_deadline）
    """
    def __init__(self):
        self.priority_boost: Dict[int, float] = {}
        self.deadline: Dict[int, float] = {}
        self.hard_next_job: Optional[int] = None
        self.weights = dict(w_prio=1.0, w_deadline=2.0)

    def reset_step(self):
        """每个RH step开始时可选择清理一次性指令（演示中不清理）"""
        pass

    def update(self, now: float, job_next: np.ndarray, job_ready: np.ndarray, mach_ready: np.ndarray, pb: FJSP):
        """
        钩子：可在这里读取外部信号/人类输入，更新优先级与硬指令。
        默认示例（模拟人类）：若有作业deadline临近，则升高其priority；在固定时刻指定一次 hard_next_job。
        """
        # 示例：在时间 30~60 内，如果 j 的释放时间 < now 且没开始下一道，则boost
        for j in range(pb.n_jobs):
            if job_next[j] < len(pb.jobs[j].ops) and pb.jobs[j].release <= now:
                # 距离释放越久仍未执行，越boost
                wait = max(0.0, now - pb.jobs[j].release)
                self.priority_boost[j] = 0.5 * (1.0 + wait/30.0)  # 线性上升
        # 示例：给前两个作业一个deadline（软）
        self.deadline.setdefault(0, 50.0)
        self.deadline.setdefault(1, 60.0)
        # 示例：在时间落在[40,45]时，试着硬指定 job 1 启动下一步
        if 40.0 <= now <= 45.0:
            self.hard_next_job = 1
        else:
            self.hard_next_job = None

# -------------------------------
# Decoder & GA (with human penalties)
# -------------------------------

class RemainingDecoder:
    def __init__(self, pb: FJSP,
                 job_next: np.ndarray, job_ready: np.ndarray,
                 mach_ready: np.ndarray,
                 downtime: Dict[int, List[Tuple[float,float]]]):
        self.pb = pb
        self.jn = job_next.copy()
        self.jr = job_ready.copy()
        self.mr = mach_ready.copy()
        self.downtime = downtime

    def decode(self, op_seq: List[int], mac_seq: List[int]) -> Tuple[float, List[Tuple[int,int,int,float,float]]]:
        events=[]
        jn = self.jn.copy(); jr = self.jr.copy(); mr = self.mr.copy()
        mac_ptr=0
        for j in op_seq:
            k = jn[j]
            if k >= len(self.pb.jobs[j].ops): continue
            feas = self.pb.feasible_machines(j, k)
            if len(feas)==0: continue
            idx = mac_seq[mac_ptr] % len(feas)
            mm = feas[idx]
            pt = float(self.pb.jobs[j].ops[k].machine_time[mm])
            st0 = max(jr[j], mr[mm])
            st, en = adjust_for_downtime(mm, st0, pt, self.downtime.get(mm, []))
            jr[j]=en; mr[mm]=en; jn[j]+=1
            events.append((j,k,mm,st,en))
            mac_ptr += 1
        mk = float(np.max(jr))
        events.sort(key=lambda x: x[3])
        return mk, events

class GA_Remaining:
    def __init__(self, pb:FJSP, decoder_factory, human:HumanInterface,
                 now_time:float, pop=60, gens=120, pc=0.9, pm=0.2, seed=0):
        self.pb=pb; self.dec_factory=decoder_factory; self.human=human; self.now=now_time
        self.pop=pop; self.gens=gens; self.pc=pc; self.pm=pm
        seed_all(seed)

    def _base_op_seq(self, job_next: np.ndarray) -> List[int]:
        seq=[]
        for j in range(self.pb.n_jobs):
            rem = len(self.pb.jobs[j].ops) - job_next[j]
            if rem>0: seq += [j]*rem
        return seq

    def rand_ind(self, job_next: np.ndarray) -> Tuple[List[int], List[int]]:
        base = self._base_op_seq(job_next)
        random.shuffle(base)
        mac=[]; seen=[0]*self.pb.n_jobs
        for j in base:
            k=seen[j]
            feas=self.pb.feasible_machines(j,k)
            mac.append(random.randrange(max(1,len(feas))))
            seen[j]+=1
        return base, mac

    def crossover_ppx(self, a_seq, b_seq):
        n=len(a_seq); need={}
        for x in a_seq: need[x]=need.get(x,0)+1
        left=dict(need); res=[]; ai=bi=0
        while len(res)<n:
            pickA = (random.random()<0.5)
            if pickA:
                while ai<n and left.get(a_seq[ai],0)==0: ai+=1
                if ai<n: j=a_seq[ai]; res.append(j); left[j]-=1; ai+=1
                else:
                    while bi<n and left.get(b_seq[bi],0)==0: bi+=1
                    j=b_seq[bi]; res.append(j); left[j]-=1; bi+=1
            else:
                while bi<n and left.get(b_seq[bi],0)==0: bi+=1
                if bi<n: j=b_seq[bi]; res.append(j); left[j]-=1; bi+=1
                else:
                    while ai<n and left.get(a_seq[ai],0)==0: ai+=1
                    j=a_seq[ai]; res.append(j); left[j]-=1; ai+=1
        return res

    def crossover_uniform(self, a, b): return [(aa if random.random()<0.5 else bb) for aa,bb in zip(a,b)]

    def mutate(self, op_seq, mac_seq, job_next):
        if random.random()<0.5 and len(op_seq)>=2:
            i,j = random.sample(range(len(op_seq)),2)
            op_seq[i],op_seq[j]=op_seq[j],op_seq[i]
        if random.random()<0.7:
            seen=[0]*self.pb.n_jobs
            for i,j in enumerate(op_seq):
                k=seen[j]
                if random.random()<0.2:
                    feas=self.pb.feasible_machines(j,k)
                    mac_seq[i]=random.randrange(max(1,len(feas)))
                seen[j]+=1

    def human_penalty(self, events: List[Tuple[int,int,int,float,float]]) -> float:
        """
        软惩罚：对重点作业越早启动越好；逾期启动罚分。
        penalty = w_prio * sum_j boost[j] * max(0, start_j - now)
                + w_deadline * sum_j max(0, start_j - deadline[j])
        """
        if len(events)==0: return 0.0
        w_prio = self.human.weights.get("w_prio",1.0)
        w_dead = self.human.weights.get("w_deadline",2.0)
        first_start = {}  # job -> first start time in this plan
        for (j,k,mm,st,en) in events:
            first_start.setdefault(j, st)
        pen = 0.0
        for j, st in first_start.items():
            boost = self.human.priority_boost.get(j, 0.0)
            if boost > 1e-12:
                pen += w_prio * boost * max(0.0, st - self.now)
            if j in self.human.deadline:
                pen += w_dead * max(0.0, st - self.human.deadline[j])
        return pen

    def evaluate(self, op_seq, mac_seq, job_next, job_ready, mach_ready, downtime):
        dec = self.dec_factory(job_next, job_ready, mach_ready, downtime)
        mk, events = dec.decode(op_seq, mac_seq)
        return mk + self.human_penalty(events)

    def run(self, job_next, job_ready, mach_ready, downtime, gens=None):
        gens = gens or self.gens
        pop=[]; fit=[]
        for _ in range(self.pop):
            ind=self.rand_ind(job_next); pop.append(ind)
            fit.append(self.evaluate(ind[0], ind[1], job_next, job_ready, mach_ready, downtime))
        for _ in range(gens):
            new=[]
            while len(new) < self.pop:
                i,j = random.randrange(self.pop), random.randrange(self.pop)
                p1 = pop[i] if fit[i]<fit[j] else pop[j]
                i,j = random.randrange(self.pop), random.randrange(self.pop)
                p2 = pop[i] if fit[i]<fit[j] else pop[j]
                c1=(p1[0][:], p1[1][:]); c2=(p2[0][:], p2[1][:])
                if random.random()<self.pc:
                    c1=(self.crossover_ppx(p1[0],p2[0]), self.crossover_uniform(p1[1],p2[1]))
                    c2=(self.crossover_ppx(p2[0],p1[0]), self.crossover_uniform(p2[1],p1[1]))
                if random.random()<self.pm: self.mutate(*c1, job_next)
                if random.random()<self.pm: self.mutate(*c2, job_next)
                new += [c1,c2]
            pop = new[:self.pop]
            fit = [self.evaluate(ind[0], ind[1], job_next, job_ready, mach_ready, downtime) for ind in pop]
        idx = int(np.argmin(fit))
        return pop[idx], fit[idx]

# -------------------------------
# Rolling-horizon simulator with HumanInterface
# -------------------------------

class DynamicSimulator:
    def __init__(self, pb:FJSP, downtime:Dict[int,List[Tuple[float,float]]], human:HumanInterface,
                 seed=0, ga_pop=50, ga_gens=80):
        self.pb = pb
        self.downtime = downtime
        self.human = human
        self.m = pb.m
        self.ga_pop = ga_pop
        self.ga_gens = ga_gens
        seed_all(seed)

        self.time = 0.0
        self.job_next = np.zeros(pb.n_jobs, dtype=int)
        self.job_ready = np.array([job.release if len(job.ops)>0 else 1e18 for job in pb.jobs], dtype=float)
        self.mach_ready = np.zeros(self.m, dtype=float)

        self.done_ops = 0
        self.total_ops = pb.n_ops_total

        self.timeline: Dict[int, List[Tuple[float,float,str]]] = {mm:[] for mm in range(self.m)}

    def remaining_exists(self): return self.done_ops < self.total_ops

    def next_release_time(self):
        tmin = 1e18
        for j, job in enumerate(self.pb.jobs):
            if self.job_next[j] < len(job.ops) and job.release > self.time:
                tmin = min(tmin, job.release)
        return tmin

    def ready_jobs(self):
        return [j for j in range(self.pb.n_jobs)
                if self.job_next[j] < len(self.pb.jobs[j].ops) and self.job_ready[j] <= self.time + 1e-9]

    def step(self):
        self.human.reset_step()
        self.human.update(self.time, self.job_next, self.job_ready, self.mach_ready, self.pb)

        ready = self.ready_jobs()
        if len(ready) == 0:
            t_rel = self.next_release_time()
            t_mr  = float(np.min(self.mach_ready))
            t_next = min(t_rel, t_mr) if t_rel < 1e17 else t_mr
            self.time = max(self.time, t_next)
            return

        # Build GA (with human)
        decoder_factory = lambda jn,jr,mr,dt: RemainingDecoder(self.pb, jn, jr, mr, dt)
        ga = GA_Remaining(self.pb, decoder_factory, self.human, self.time,
                          pop=self.ga_pop, gens=self.ga_gens, seed=random.randrange(1<<30))
        (op_seq, mac_seq), _ = ga.run(self.job_next, self.job_ready, self.mach_ready, self.downtime, gens=self.ga_gens)

        # Decode once to get plan
        dec = decoder_factory(self.job_next, self.job_ready, self.mach_ready, self.downtime)
        _, plan = dec.decode(op_seq, mac_seq)
        if len(plan)==0:
            self.time = max(self.time, min(self.next_release_time(), float(np.min(self.mach_ready))))
            return

        # Human hard override: if set, try to take earliest finishing operation from that job
        chosen = None
        if self.human.hard_next_job is not None:
            cand = [(j,k,mm,st,en) for (j,k,mm,st,en) in plan if j == self.human.hard_next_job and st <= self.time + 1e-9]
            if len(cand)>0:
                chosen = sorted(cand, key=lambda x: x[4])[0]
            else:
                # if not feasible now, try earliest-start op of that job
                cand2 = [(j,k,mm,st,en) for (j,k,mm,st,en) in plan if j == self.human.hard_next_job]
                if len(cand2)>0:
                    chosen = sorted(cand2, key=lambda x: x[3])[0]
                    self.time = max(self.time, chosen[3])  # fast-forward to its start

        # If no hard choice, use earliest-end among st<=now
        if chosen is None:
            feas_now = [(j,k,mm,st,en) for (j,k,mm,st,en) in plan if st <= self.time + 1e-9]
            if len(feas_now)==0:
                chosen = sorted(plan, key=lambda x: x[3])[0]
                self.time = max(self.time, chosen[3])
            else:
                chosen = sorted(feas_now, key=lambda x: x[4])[0]

        j,k,mm,st,en = chosen
        dur = en - st
        self.timeline[mm].append((st, dur, f"J{j}-O{k}"))
        self.time = en
        self.job_next[j] += 1
        self.job_ready[j] = en
        self.mach_ready[mm] = en
        self.done_ops += 1

    def run(self, verbose=True, max_steps=100000):
        steps=0
        while self.remaining_exists() and steps<max_steps:
            self.step()
            steps += 1
            if verbose and steps%100==0:
                pct = 100.0*self.done_ops/self.total_ops
                print(f"[Sim] t={self.time:.2f}  done={self.done_ops}/{self.total_ops} ({pct:.1f}%)")
        return self.time, self.timeline

# -------------------------------
# Demo
# -------------------------------

def main():
    seed_all(0)
    # 1) 动态实例
    pb, downtime = build_dynamic_fjsp(
        n_jobs=18, m=6, ops_per_job=4, k_machines_per_op=2,
        pt_low=1, pt_high=9, release_span=40.0,
        mtbf=120.0, mttr=20.0, horizon=400.0, seed=7
    )

    # 2) 人类接口（示例：程序化“人类判断”）
    human = HumanInterface()
    human.weights = dict(w_prio=0.8, w_deadline=3.0)  # 惩罚强度可调

    # 3) 仿真（滚动GA + 人机协同）
    sim = DynamicSimulator(pb, downtime, human, seed=1, ga_pop=60, ga_gens=90)
    mk, timeline = sim.run(verbose=True)
    print(f"\nFinal makespan (Human-in-the-Loop) = {mk:.2f}")

    # 4) 甘特图（含停机窗）
    plot_gantt_with_downtimes(pb.m, timeline, downtime,
                              title=f"Dynamic FJSP + Human-in-the-Loop (Cmax={mk:.1f})")

if __name__ == "__main__":
    main()
